About two years ago, in early 2023, I made my first contribution to ToyotaKit
at Toyota Australia
.
My task was to add a set of icons to the component library, which I tackled creatively to ensure the solution was both maintainable and well-guarded by Typescript
. This project remains one of my proudest contributions at Toyota
, receiving positive feedback from engineers and engineering managers alike.
Today, I won’t dive into every detail of that project, but I want to share an advanced type definition from my Typescript
work there, using a different example. In this example, I’ve adjusted the structure from Categories > Icons > Variants
to Categories > Products > Variants
.
We have a taxonomy
of products, defined statically on the client-side in our Typescript
code. This data source is maintained on the client-side, allowing for new categories, products, and variants to be added over time.
While category names are unique, product and variant names are not. Although this structure may seem less intuitive for products, it’s entirely logical for icons, where icons under different categories can share the same name. Icon variants were essentially different sizes, like 16px
, 24px
, and so forth.
We also have a component that takes a product-variant as an argument and renders something based on it. For icons, this would mean rendering the specific icon size. Since product and variant names aren’t unique, the most efficient way to identify them is to include the full path from category to variant in the identifier.
Here’s an example of what this product taxonomy might look like:
{
"Electronics": {
"Computers": {
"Laptops": ["Gaming Laptop", "Ultrabook", "Notebook"],
"Desktops": ["Gaming Desktop", "All-in-One"]
},
"Phones": {
"Smartphones": ["Android", "iOS"],
"Feature Phones": ["Basic Phone"]
}
},
"Furniture": {
"Living Room": {
"Sofas": ["Sectional Sofa", "Loveseat", "Recliner"],
"Tables": ["Coffee Table", "End Table"]
},
"Bedroom": {
"Beds": ["Queen Bed", "King Bed"],
"Wardrobes": ["Sliding Door Wardrobe", "Hinged Door Wardrobe"]
}
}
}
With the taxonomy above, the identifiers could look like this:
[
"Electronics/Computers/Laptops/Gaming Laptop",
"Electronics/Computers/Laptops/Ultrabook",
"Electronics/Computers/Laptops/Notebook",
"Electronics/Computers/Desktops/Gaming Desktop",
"Electronics/Computers/Desktops/All-in-One",
"Electronics/Phones/Smartphones/Android",
"Electronics/Phones/Smartphones/iOS",
"Electronics/Phones/Feature Phones/Basic Phone",
"Furniture/Living Room/Sofas/Sectional Sofa",
"Furniture/Living Room/Sofas/Loveseat",
"Furniture/Living Room/Sofas/Recliner",
"Furniture/Living Room/Tables/Coffee Table",
"Furniture/Living Room/Tables/End Table",
"Furniture/Bedroom/Beds/Queen Bed",
"Furniture/Bedroom/Beds/King Bed",
"Furniture/Bedroom/Wardrobes/Sliding Door Wardrobe",
"Furniture/Bedroom/Wardrobes/Hinged Door Wardrobe"
]
Given our application’s requirements, we need to maintain both the taxonomy and the unique keys. This means that every time we make a change, we have to update two separate data sources. Here are a few reasons why this could lead to problems over time:
What if we could infer the unique keys as a Union Type
from the taxonomy itself? This would mean only one data source to maintain, eliminating all of the issues listed above.
With the full power of Typescript
, this approach is entirely possible.
First, we need to define the types. It’s impressive how advanced Typescript
syntax can behave almost like a function, allowing us to traverse structures like taxonomies recursively.
Below is a helper function designed to navigate a tree structure like our taxonomy. This function goes through each level of the taxonomy and concatenates the keys. We’ve set the maximum depth to 10
, which is adjustable if needed, though this depth should be more than sufficient for most cases.
/**
* Helper to traverse a nested structure recursively
* and generate a type based on nested keys.
*/
type Next = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...never[]];
Next, we define another type that can join two strings with a custom delimiter. This will be used to concatenate the keys.
/**
* A generic type based on joining two strings with a delimiter.
* If at least one of the values is not string, the result would be 'never'!
*
* e.g.
* JoinStrings<"first", "second"> => "first.second"
* JoinStrings<"first", "second", "-"> => "first-second"
* JoinStrings<"first", ""> => "first"
* JoinStrings<"first", 2> => never
*/
type JoinStrings<K, P, Delimiter extends string = "."> = K extends string
? P extends string
? `${K}${"" extends P ? "" : `${Delimiter}`}${P}`
: never
: never;
Now, the key part is defining a type to traverse the taxonomy. I’ve adjusted the logic slightly to showcase Typescript
’s capability for defining conditional logic. When concatenating the keys, this type joins the first two keys with /
and the rest with --
. Here, extends
checks if a condition is met, with D extends 0
essentially meaning D === 0
.
type Leaves<T, D extends number = 0> = D extends never
? never
: T extends object
? {
[K in keyof T]-?: T[K] extends string
? T[K]
: K extends string
? JoinStrings<K, Leaves<T[K], Next[D]>, D extends 0 ? "/" : "--">
: never;
}[keyof T]
: "";
Now, it’s time for the final step—creating a Union Type from our taxonomy. Before we do that, though, we need to mark the arrays in our taxonomy as const
. This change should look like:
const taxonomy = {
"Electronics": {
"Computers": {
"Laptops": ["Gaming Laptop", "Ultrabook", "Notebook"] as const,
"Desktops": ["Gaming Desktop", "All-in-One"] as const
},
"Phones": {
"Smartphones": ["Android", "iOS"] as const,
"Feature Phones": ["Basic Phone"] as const
}
},
"Furniture": {
"Living Room": {
"Sofas": ["Sectional Sofa", "Loveseat", "Recliner"] as const,
"Tables": ["Coffee Table", "End Table"] as const
},
"Bedroom": {
"Beds": ["Queen Bed", "King Bed"] as const,
"Wardrobes": ["Sliding Door Wardrobe", "Hinged Door Wardrobe"] as const
}
}
}
And finally, we have:
type ProductKey = Leaves<typeof taxonomy>;
/**
* Result:
*
* "Electronics/Computers--Laptops--Gaming Laptop"
* "Electronics/Computers--Laptops--Ultrabook"
* "Electronics/Computers--Laptops--Notebook"
* "Electronics/Computers--Desktops--Gaming Desktop"
* "Electronics/Computers--Desktops--All-in-One"
* "Electronics/Phones--Smartphones--Android"
* "Electronics/Phones--Smartphones--iOS"
* "Electronics/Phones--Feature Phones--Basic Phone"
* "Furniture/Living Room--Sofas--Sectional Sofa"
* "Furniture/Living Room--Sofas--Loveseat"
* "Furniture/Living Room--Sofas--Recliner"
* "Furniture/Living Room--Tables--Coffee Table"
* "Furniture/Living Room--Tables--End Table"
* "Furniture/Bedroom--Beds--Queen Bed"
* "Furniture/Bedroom--Beds--King Bed"
* "Furniture/Bedroom--Wardrobes--Sliding Door Wardrobe"
* "Furniture/Bedroom--Wardrobes--Hinged Door Wardrobe"
*/
Now, when defining a variable of type ProductKey
, we benefit from type checking and autocomplete features.
Thanks for reading!